【第1679其】函数式编程浅析
前言
今日早读文章由知乎@zhangwang投稿分享。
正文从这开始~~
之前的我模糊的知道,函数式编程(下文简称 FP) 「简洁」、「数学」、「优雅」,但是仔细一想,我并不理解这些词具体的含义。当我看到别人号称是 FP 风格的代码时,会觉得晦涩难懂。这其实就是问题所在,一个大家都觉得好的东西,我竟然不理解。
最近花了比较久的一段时间看了一些函数式编程的资料,还在公司组内进行了一次关于函数式编程的分享,本文是分享对应的文字稿。
注:很多语言都支持函数式编程,文中代码已 JavaScript 为例。
在阅读下文之前,可以花一分钟时间阅读以下两种风格的代码,看看那一种更容易被理解。
// 风格一 指令式编程 imperativevar numbers = [4,10,0,27,42,17,15,-6,58];var faves = [];var magicNumber = 0;
pickFavoriteNumbers();
calculateMagicNumber();
outputMsg(); // The magic number is: 42// ***************function calculateMagicNumber() {
for (let fave of faves) {
magicNumber = magicNumber + fave;
}}function pickFavoriteNumbers() {
for (let num of numbers) {
if (num >= 10 && num <= 20) {
faves.push( num );
}
}}function outputMsg() {
var msg = `The magic number is: ${magicNumber}`;
console.log( msg );}// 风格二 声明式编程 declarativevar sumOnlyFavorites = FP.compose( [
FP.filterReducer( FP.gte( 10 ) ),
FP.filterReducer( FP.lte( 20 ) )] )( sum );var printMagicNumber = FP.pipe( [
FP.reduce( sumOnlyFavorites, 0 ),
constructMsg,
console.log
] );var numbers = [4,10,0,27,42,17,15,-6,58];
printMagicNumber( numbers ); // The magic number is: 42// ***************function sum(x,y) { return x + y; }function constructMsg(v) { return `The magic number is: ${v}`; }
我第一次看到第二种风格的代码时,觉得满头雾水。不过我现在觉得还挺自然的,希望你再阅读完本文之后,会觉得两种风格理解起来都还挺自然的。
对我个人来说,函数式编程打开了新世界的大门,让我能用一种不同的思维方式来思考问题。但是也觉得如果想要掌握,可能少不了数年的练习和思考,目前我对函数式编程的理解还很浅,一些比较高深的内容(比如 FP 背后的数学原理)本文不会涉及。
从诗词说起
前段时间在上班路上会背诗词。有一些诗词,画面感十足,简单几个字就能在我的脑海里勾勒出一副生动的画面。
如:
「枯藤老树昏鸦,小桥流水人家」
「鸡声茅店月,人迹板桥霜」
有一些则稍微难理解一些,如:
「松风吹解带,山月照弹琴」
诗词是否好理解,与我们是否熟悉其中的每一个词强相关。比如在「松风吹解带,山月照弹琴」中,不好理解的地方在于「解带」一词,如果我们能进一步了解到「解带」有田园的,洒脱的,自由自在的意思,可能就容易理解诗人所喜欢的就是隐居自由的生活。
「解带」出自:
陶渊明当初做彭泽令的时候,办事员通知他说:督邮大人要来视察工作了,您必须“束带见之”。陶渊明因此说出了那句千古名言:“吾安能为五斗米折腰,拳拳事乡里小人耶(yé)?”说完这句话,他就辞官去过田园生活了。
从文学意象的角度来看,“解带”和“束带”代表着截然相反的两种人生追求。
「解带」这类词语被称作「文学语码」,类似的还有很多,比如
「散发」代表自由,
「流水」代表时间流逝,
「贾谊」代表怀才不遇,
「王昭君」代表有节操等等。
它们的存在让诗词更有润味,同时也让诗词更为抽象。
说到抽象,我们再回到编程,从某一种角度看,编程和诗歌还挺像的,都是为了讲一个「故事」。只不过在编程中,我们用的是代码的组合。在阅读代码时,我们都希望能尽快了解其主旨,这要求代码具备较好的可读性,函数式编程为提高代码的可读性提供了一种方案。
为什么要使用函数式编程
在我看来,使用函数式编程最大的好处在于其能提高我们代码的可读性。
编程中有下面这种说法:
你不理解的代码,实际上就是不值得信任的代码。你不信任的代码,实际上就是你不理解的代码[1]。
所谓的理解实际上是指不执行,仅仅读一下,你就能知道某段代码到底要做什么。
已 map 和 for 为例来说:看到 map 就算我们不去细读其中的代码,也能想到这里想做的是对原始的值进行一定的转换并返回一个新的值;看到 for,不去进一步的阅读其中的代码,你并不能很快的知晓这段代码要做什么。
当然可读性与对熟练程度也强相关。印象还很深,最初接触 map 时,我并没有马上感觉到它很好,我还是会觉得别扭,但是后来很多情况下就会首选使用 map 。
类似诗词中的语码那样,我们需要熟悉函数式编程中的一些概念,才能更好的理解函数式语句。这种熟悉需要一定的成本,所以在很多人看来,函数式编程和可读性存在下面这张图这样的关系[2]:
一些函数式编程中的概念学习成本不高,而且能极大的提高代码的可读性,比如 map,filter ,所以大部分人在刚刚开始接触函数式编程会觉得其还挺好的,但是比如对reduce,compose,transduce,刚开始接触就会觉得不熟悉,不好理解,也就觉得可读性低。不过觉得不好理解还是不熟悉造成的,多看多联系,你会发现无论是书写还是阅读都变得更为自然起来。
这里我们简单介绍一下 imperative 和 declarative
指令式编程(imperative)关注告诉计算机如何一步步的做某件事情,这也是大多数人习惯的代码风格;
声明式编程(declarative)则关注每一步的输出,函数式编程则从本质上更具声明式特点。
声明式编程比指令式编程要容易理解。计算机擅长指令式,而我们擅长声明式。
如果我们合理的使用了函数式编程,我们的代码主体可能就会变成一个个「独立且拥有合适名称的纯函数」,若干「纯函数的组合」,「集中处理的副作用」。想要了解整体代码逻辑,只需去阅读函数组合在语意上做了什么事情,副作用做了什么,想要了解具体某个纯函数做了什么时,可以单独去查看其定义,而不用去关心其它的函数。
纯函数非常容易写测试,这就能保障我们大多数的代码不会容易出问题,如果真的出现了 bug ,bug 可能主要会出现在少数副作用相关的地方,能更容易被找到和修复。
为了更好的理解函数式编程,我们需要重新看一下什么是函数?
函数式编程中的「函数」
在我初学编程时,看到的函数定义是这样的:
a function is a collection of code that can be executed one or more times.
函数是一系列代码的集合,可以被执行一次或者多次。
已代码为例,就是类似下面这样:
function foo(x,y,z,w){
console.log(x)
console.log(y,z,w)
const sum = x + y + z + w
console.log(sum)}
对我来说「函数」这个词最初出现在数学课里. 数学 中为两 集合 间的一种对应关系:输入值集合中的每项元素皆能对应唯一一项输出值集合中的元素。比如:y = x² + 3,有一个输入 x ,就能得到一个唯一的输出 y。如下图所示
初学编程时,按照编程中函数的定义,函数的输入和输出之间并没有明确的关系,这个问题困扰了我很久,让我一直没有办法把编程中的函数与数学中的函数建立起联系,直到最近学习函数式编程,才知晓我们熟悉的编程中函数实际上其是伪装在 function 关键字下的 procedure(过程)。
a procedure is a collection of operations. 过程是一系列操作的集合
a function is a relationship between the input and the output.函数是输入和输出之间的关系
在数学里,函数一定拥有输入和输出
函数式编程中的函数拥抱的是数学概念中的函数。函数要做的事情就是对输入的数据进行一定的处理后输出。
接下来,我们分别看一下输入(input) 和输出(output)分别是什么。
函数输入 input
输入其实就是函数的参数,一个函数可能会具有不同个数的参数,函数参数个数有一个单独的名字,叫做 arity(元数),比如说:
unary:一元
binary:二元
n-ary:n元
示例如下:
function a(x,y){}
a.length // 2function b(x,y=2){}
b.length // 1function c(x,...args){}
c.length //1
关于函数的参数,你可能听说过两个词:Arguments 和 parameters
Arguments are the values you pass in, and parameters are the named variables inside the function that receive those passed-in values.
parameters 的个数可以通过 fn.length 获取,其在定义时决定,而arguments.length 属性则在运行时决定。
函数式编程中我们应当尽量避免不确定个数参数的函数。更具体的说,函数式编程者更倾向于使用一元函数。
函数输出 output
在 JS 中,函数永远都有一个输出的值。默认是 undefined,通过通过 return 来输出值。一个函数中是可以存在多个return的,不过函数式编程不鼓励这样做,return 除了返回值,还会控制流程,这会让函数的目的变得不清晰。
在 JS 中,return 返回的值也可以是一个函数,这其实是我们熟悉的高阶函数。实际上高阶函数可能算是函数式编程的基石之一了,函数式编程从某种意义上讲就是在不断的创建各种高阶函数。
the shape of function
输入输出的个数,决定了函数的「形状」(shape)。
the number and the kinds of things you pass into it, as well as the number and the kinds of things that come out of it.
比如说 :
一元函数(unary function):单输入,单输出
二元函数(binary function):两个输入,单个输出
函数式编程少不了函数之间的组合,如果函数的 shape 相匹配,函数就能比较容易的组合起来。
下面是一个简单的例子:
getPerson(function onPerson(person) {
return renderPerson(person)})
通过等式推理(equational Reasoning),上面的函数可以写做下面这样:
getPerson(renderPerson)
函数具备相同的 shape 时是可互换的(interchangeable),在学习函数式编程之前,我也曾在代码中写过这种风格的代码,只不过之前一直没有那么理解。
上面这种移除无关参数的函数组合方式在函数式编程中非常常见,这种代码风格也被叫做 tacit programming 或者用更口语的说法是 point-free [3]。使用这种风格的关键点在于,外层函数的参数会被相同的传入返回的函数。
下面是一些其它的关于 shape 的例子,这种形式在函数式编程中很常见,多写多看后,你也会变得很熟悉。
比如我们写一个判断奇偶的函数:
function not(fn) {
return function negated(...args) {
return !fn(...args)
}}function isOdd(v) {
return v % 2 == 1}// point-freeconst isEven = not(isOdd)
isEven(4)
如果我们继续抽象,还可以写成下面这样:
function mod(y) {
return function forX(x) {
return x % y
}}function eq(y) {
return function forX(x) {
return x === y
}}function isOdd(x) {
return eq1(mod2(x))}function compose(fn2, fn1) {
return function composed(v) {
return fn2(fn1(v))
}}
iOdd = compose(
eq(1),
mod(2))
也许你初看上面的代码会觉得其可读性并不好,但是不要紧,这里我们觉得可读性差的原因并非是它不好,而是我们不熟悉。函数式编程中有很多类似 compose 这样的工具函数,它们就像是函数世界里的乐高积木,足够熟悉之后,你总能顺手找到帮你连接起整个系统的那一款。
组合输入输出
函数的 shape 对函数的组合至关重要,上文也提到函数式编程倾向于使用一元函数,但是实际上我们很难保证所用的函数都是一元函数,存在一些办法我帮我们进行元数的转换,有两种常见的方案,partial(偏函数)和 curry(柯里化)。
partial
partial(偏函数)是一种减少函数元数(arity)的方法,其核心是,它会创建一个新函数,这个新函数的一些参数是预设好的。
下面是 partial 使用 JS 的一种实现:
function partial(fn,...presetArgs) {
return function partiallyApplied(...laterArgs){
return fn( ...presetArgs, ...laterArgs );
};}function add(x,y) {
return x + y;}[1,2,3,4,5].map( partial( add, 3 ) );
在 JS 中,通过 Function.prototype.bind() 也能原生实现 partial,只是需要传入 this ,这不太好。
curry
柯里化则可以看作偏函数的一种特例,其元数会被减少到 1 ,其通过一系列连续的函数调用实现,每个函数都接受一个参数,一旦所有的参数被这些函数调用具体化了,原始函数会基于收集到的所有参数执行。
以下是 curry 使用 JS 中的一种实现:
function curry(fn,arity = fn.length) {
return (function nextCurried(prevArgs) {
return function curried(nextArg) {
var args = [ ...prevArgs, nextArg ];
if (args.length >= arity) {
return fn( ...args );
}
else {
return nextCurried( args );
}
};
})( [] );}function sum(...nums) {
var total = 0;
for (let num of nums) {
total += num;
}
return total;}var curriedSum = curry( sum, 5 );
curriedSum( 1 )( 2 )( 3 )( 4 )( 5 );
curriedSum 展开后实际上是下面这样的
function curriedSum(v1) {
return function(v2){
return function(v3){
return function(v4){
return function(v5){
return sum( v1, v2, v3, v4, v5 );
};
};
};
};}// 如果写作箭头函数还可以这样写
curriedSum = v1 => v2 => v3 => v4 => v5 => sum( v1, v2, v3, v4, v5 );
至少对我来说,展开后并不直观,不好理解,不过如果用工具函数 curry 代码整体就好理解得多了。
使用 curry / partial 对我们的函数进行处理有以下好处:
通过这些转换,我们的函数能更容易组合,这一点在 curry 上更为明显,比如说一个函数如果需要三个参数, 柯里化之后能转化为需要一个参数的函数, 只是会包裹三层;
另外一个大的好处在于,这些转换让我们的函数更为具体, 这能提高我们代码的可读性;
了解了上面这些基础,我们再来看看,如何写好一个函数式编程需要的函数。
写好一个函数
函数的名称
函数式编程中的每一个函数希望能有一个名字。就像诗词是通过一个个词语组合成有意义的句子一样,函数式编程中有着函数的组合,这要求我们的函数有一个合适的函数名。
函数名很重要:
在开发者工具中,函数名能出现在调用栈中,如果遇到错误,能帮我们快速排查是哪里出了问题;
在需要自引用的地方,函数名必不可少,比如递归或者事件处理函数;
在 JS 中匿名函数也有非常广的用途,常见比如 IIFEs,匿名函数用起来很方便,让我们省去了对函数进行命名的痛苦(毕竟起一个合适的函数名是编程中最困难的事情之一了)。不过匿名函数实际上是 「易写」和「不易读」的一种交易,对于需要长期维护的代码,这并不划算。而且换个角度想,如果一个函数命名困难,最大的可能就是函数的意图不清,也许这时候应该重构函数了。
函数名其实就是词汇,我们需要用有意义的词汇讲述想要表达的故事。想写出好诗一般的语言,一个好函数名必不可少。
减少副作用
副作用会影响我们代码的可读性和代码质量,副作用会让代码变得难以理解,因为改动起来麻烦,副作用往往还是 bug 之源。
函数式编程并非要避免副作用,关于副作用,函数式编程的态度是尽可能的减少副作用。
在编程中 side effect 无处不在
I/O (console,files,etc)
Database Storage
Network Calls
DOM
TimeStamps
Random Numbers
CPU Heat
CPU Time Delay:对系统有影响
编程实际上离不开副作用,我们应该做的实际上是尽可能的减少副作用,我们应当确保副作用是我们故意添加的(而非无意的引入代码中的),而且添加的副作用应当尽可能的明显。
为了减少副作用,函数式变成鼓励我们尽可能的写纯函数(pure function)。
pure functions
具备直接的输入输出,没有副作用的函数被称作纯函数。
纯函数具备以下特征:
幂等性,给定输入,输出是确定的;
引用透明(referential transparency),所谓引用透明指的是,函数调用能被函数输出值直接替代;
纯函数的定义很简单,但是也容易有一些争议。比如考虑下面这两个函数是不是纯函数:
const z = 1;// 函数一 addTwofunction addTwo(x,y){
return x + y
}// 函数二 adAnotherfunction addAnother(x,y){
return addTwo(x,y) + z;}
addTwo 明显是一个纯函数,但是 addAnother 则存在争议。有的人会觉得 z 通过 const 定义,是不可变的,所以addAnother 是纯函数,有的人会觉得 z 并非直接的输入,所以这个函数不是纯函数。如果完全抠定义 addAnother 算是纯函数,但是明显它不是一个好的纯函数。
好的纯函数还要求我们不用去看上下文就能独立的分析它。addAnother 更好的写法如下:
function addAnother(z){
return function addTwo(x,y){
return x + y + z;
}}
addAnother(1)(2,3)
纯函数也许不是一个简单的二元问题,有人针对纯函数还提出了一个「纯净度」[4]的概念,某一个函数越能让人清醒的理解其意图,其纯净度越高。比如说第一个版本的 addAnother 就是一个纯净度不高的纯函数,但是纯净度是一个相对的概念,如果在我们的工作中约定大写的值就代表不可变的值。按照下面这些写,也许也算是一种纯净度很高的函数
const CONSTANT = 1;function addAnother(x,y){
return addTwo(x,y) + CONSTANT;}
函数的纯净度其实也反应了我们对某一段代码的信心程度。阅读代码过程中,比较理想的效果其实是如果我们想要知道某段代码的含义,我们看一看它就行,而不用真的去执行它,执行只是为了验证我们的想法。
我们再看一个复杂一点的例子:
function repeater(count){
var str
return function allTheAs(){
if(str == undefined) {
str = "".padStart(count,"A")
}
return str;
}}var A = repeater(10);
A()
A()
上面的示例也是一个纯函数,但是一眼看去也许并不能看去它还会缓存之前的计算结果,所以纯净度也不高,更直观的写法如下:
function repeater(count) {
return memoize(function allTheAs(){
return "".padStart(count,"A")
})}var A = repeater(10);
A();
A();
我们可以先不去考虑 memoize 具体的实现,有很多工具函数库都提供这么一个方法,用了这么一个工具函数,代码的可读性明显能提高。如果不能做到易读,易懂,在 JS 中进行函数式编程意义就没有那么大了。
写纯函数也有一些常见的模式,想要了解更多,可以参看这里[5]
了解了如何写一个函数式编程需要的函数,下面我们再来看,函数之间应当如何组合:
Composition
从某种意义上讲,函数的组合(Composition) 算是 FP 中最基础的概念了,组合决定了程序中数据的流通方式。组合是声明性的数据流,它是通过一系列以声明方式而非命令方式定义的操作的数据流。这意味着我们的代码以明确,明显和可读的方式描述数据流。
狭义上讲,组合指的是将一个函数的输出立即传入另外一个函数当作输入。
通过组合,我们完全可以把数据处理的过程,转变成一种与参数无关的合成运算。
我们用一组图来说明。
我们对比上述三图,
图1 是一种传统的生产糖果的方式,输入原材料,拿到中间变量,通过传送带传入下一步,这样一步步的进行,直到拿到最终的结果;
而 Composition 就像图2,其移除传送带(减少中间变量的限制),直接把上一步的输出当作下一步的输入,最终这个过程可以看作一个整体。
关于上图的示例,在这里[6]中有非常充分的讲解,非常推荐阅读
关于 Composition ,有很多工具函数,我们已compose 和 pipe 为例说明:
compose: 函数从右向左执行;
pipe: 函数从左向右执行;
function compose(...args) {
return pipe(...fns.reverse())}function pipe(...fns) {
return function piped(v){
for(let fn of fns){
v = fn(v)
}
return v
}}
Composition 还满足数学中的结合性。如下:
function minus2(x) {
return x - 2}function triple(x) {
return x * 3}function increment(x) {
return x + 1}function composeTwo(fn2, fn1) {
return function composed(v) {
return fn2(fn1(v))
}}const f = composeTwo(composeTwo(minus2, triple), increment)const p = composeTwo(minus2, composeTwo(triple, increment))
f(4)
p(4)
recursion
递归是函数式编程中常见的模式。相比循环而言,递归可能可读性更高。
比如说我们看以下两个 sum 实现:
function sum(total,...nums) {
for (let num of nums) {
total = total + num;
}
return total;}// vsfunction sum(num1,...nums) {
if (nums.length == 0) return num1;
return num1 + sum( ...nums );}
一般来说,递归需要满足以下两个条件:
基线条件(base case):函数不再调用自己的条件
递归条件(recursive case): 函数调用自己的条件
递归虽然易懂,但其最大的缺点在于耗内存。不过也有如下几种方式可以优化:
tail calls,可能大家听说最多的递归优化方案就是尾调用,不过尾调用需要系统,语言,编译器,运行环境等等都支持才行,目前只有 Safari 浏览器支持,需要在 strict 模式,且递归直接返回函数时才生效;
CPS:一种欺骗性的突破栈内存限制的方法;
trampolines:一种真实有效的节省内存的方式;
上述几种优化递归内存的方式非常有意思,想要了解细节,可以查看这里[7]。
对列表的操作
列表做为数据的主要载体之一,函数式编程为其提供了一些常见的操作方法。这些方法返回的都是一个新的值。常见的有 map,filter,reduce 三种。
map
map 对应着值的转换。
说到 map ,一个相关的概念是 functor (函子),简单来说函子指的是范畴之间的映射,map 可以看作其在编程中的实现。
filter
filter 对应着值的过滤
reduce
reduce 则对应着值的组合
reduce 就像一把瑞士军刀,我们可以用它做很多事情,任何有两个参数的函数从某种意义上都可以看作 reducer。
使用 reduce 可以很容易的实现 map 和 filter,不过值得一提的是,在一些语言中,filter 和 map 实际上是可以多线程并行处理的。但是 reduce 则不能。
以下是 reduce 用 JS 的一种实现方式。
function reduce(reducer,initialValue,arr){
var ret = initialVal
for(let elem of arr){
ret = reducer(elem)
}
return ret
}
JS 中的 Array 内置了map,filter 和 reduce,不过也是在学习函数式编程后,才知道这些方法是函数式编程友好的。
一些其它的概念
函数式编程中还存在着一些比较高级的概念。比如 transduction 和 monad,我对他们的理解也还不深刻,在此只做简单的叙述。
transduction
通俗的讲,将 map reducer filter 组合到一起的技术叫做 transduction。它是一种声明式的解决方案,熟练掌握也能提高我们代码的可读性。
比如说我们常常按照下面这样使用 reduce
list.reduce((total,v){
v = add1(v)
if(isOdd(v)){
total = sum(total,v)
}
return total
},0)
上述代码中还是参杂着很多命名式的语句,让我们不得不在阅读整体的时候,去了解细节,更好的写法如下:
var transducer = compose(
mapReducer(add1),
filterReducer(isOdd))into(transducer,0,[1,3,4,5,6])
monad
说到函数式编程,很多人都会提到 monad,monad 是一种创建函数式友好的数据结构的方法,数据结构其实不仅仅关于值,还关乎这些值能进行的操作。更通俗的讲,monad 可以看作一个 wrapper,通过 monad 处理后,一个值就能拥有一些特定的行为。比如说 Promise.resolve() 在也许从某种意义上讲就是一种 monad。让我们的值拥有 Promise 相关的一些特性。
monad 的一个特征在于,它能将我们的单个值转变为 functor。示例如下:
function Just(val) {
return { map, chain, ap, inspect };
// *********************
function map(fn) { return Just( fn( val ) ); }
// aka: bind, flatMap
function chain(fn) { return fn( val ); }
function ap(anotherMonad) { return anotherMonad.map( val ); }
function inspect() {
return `Just(${ val })`;
}}var A = Just( 10 );var B = A.map( v => v * 2 );
B.inspect(); // Just(20)
我们构建了一个 JSUT monad ,让 A=Just(10) 后,A 就拥有了JUST 提供的各种方法。
推荐阅读
函数式编程工具库有很多,如果你已经熟练使用 lodash ,不妨先尝试 lodash/FP ,不过如果想要更加函数式,推荐使用 Ramda。熟练使用相关的工具函数,能让我们的代码更易读。
也有很多函数式编程相关的书籍或网课,以下三本书都备受好评
「functional light JS」
Functional-Light-JS - github
mostly-adequate-guide
Composing Software: The Book
其中 functional light JS 有网课版和电子版,作者是getify (you don’t know JavaScript)。电子书和网课我都看过,收获很大。另外两本我没有完全看完,不过听说已久,在很多地方被推荐过。
GitHub 也有一些值得参考的仓库
awesome-fp-js
Jargon from the functional programming world in simple terms!
函数式编程是一个很大的话题,真正掌握可能会需要若干年的练习。愿我们都能写出诗一般的代码。
关于本文
作者:@zhangwang
原文:https://zhuanlan.zhihu.com/p/74777206
最后,他曾分享过
为你推荐